Micron Document
🎖️GitЯра🎖️

Commit 94ceaa64eef1f71431b37a12cca880f4d7a0b335


Parents : bbef767
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T18:28:39Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T18:28:39Z

fix(settings): show REDACTED for a remote node's withheld private key (#6649)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index bb8bc67094..6b9c8dcb83 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -266,6 +266,7 @@ config_security_admin_key
config_security_debug_log_api_enabled
config_security_is_managed
config_security_private_key
+config_security_private_key_remote
config_security_public_key
config_security_serial_enabled
configuration
@@ -1401,6 +1402,7 @@ rebroadcast_mode_none_desc
recent_network_devices
reconnecting
red
+redacted
refresh
refresh_metadata
regenerate_keys_confirmation

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index bd0fb922b0..bc1d1fbbb8 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -287,6 +287,7 @@
<string name="config_security_debug_log_api_enabled">Output live debug logging over serial, view and export position-redacted device logs over Bluetooth.</string>
<string name="config_security_is_managed">Device is managed by a mesh administrator, the user is unable to access any of the device settings.</string>
<string name="config_security_private_key">Used to create a shared key with a remote device.</string>
+ <string name="config_security_private_key_remote">The device does not share its private key over remote administration. You can set a new key, but it can never be read back.</string>
<string name="config_security_public_key">Generated from your private key and sent out to other nodes on the mesh to allow them to compute a shared secret key.</string>
<string name="config_security_serial_enabled">Serial Console over the Stream API.</string>
<string name="configuration">Configuration</string>
@@ -1443,6 +1444,7 @@
<string name="recent_network_devices">Recent Network Devices</string>
<string name="reconnecting">Reconnecting…</string>
<string name="red">Red</string>
+ <string name="redacted" translatable="false">***[REDACTED]***</string>
<string name="refresh">Refresh</string>
<string name="refresh_metadata">Refresh metadata</string>
<string name="regenerate_keys_confirmation">Are you sure you want to regenerate your Private Key?\n\nNodes that may have previously exchanged keys with this node will need to Remove that node and re-exchange keys in order to resume secure communication.</string>

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt
index bd7a5648e1..05d5b7e418 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditBase64Preference.kt
@@ -61,6 +61,7 @@ fun EditBase64Preference(
value: ByteString,
enabled: Boolean,
readOnly: Boolean = false,
+ placeholderText: String? = null,
keyboardActions: KeyboardActions,
onValueChange: (ByteString) -> Unit,
onGenerateKey: (() -> Unit)? = null,
@@ -85,6 +86,7 @@ fun EditBase64Preference(
onGenerateKey != null && !isFocused -> MeshtasticIcons.Refresh to stringResource(Res.string.reset)
else -> null to null
}
+ val placeholder: @Composable (() -> Unit)? = placeholderText?.let { text -> { Text(text = text) } }
Column(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
OutlinedTextField(
value = valueState,
@@ -96,6 +98,7 @@ fun EditBase64Preference(
enabled = enabled,
readOnly = readOnly,
label = { Text(text = title) },
+ placeholder = placeholder,
isError = isError,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
index bc5c5f9385..365e2b73d0 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
@@ -45,6 +45,7 @@ import org.meshtastic.core.resources.config_security_admin_key
import org.meshtastic.core.resources.config_security_debug_log_api_enabled
import org.meshtastic.core.resources.config_security_is_managed
import org.meshtastic.core.resources.config_security_private_key
+import org.meshtastic.core.resources.config_security_private_key_remote
import org.meshtastic.core.resources.config_security_public_key
import org.meshtastic.core.resources.config_security_serial_enabled
import org.meshtastic.core.resources.debug_log_api_enabled
@@ -53,6 +54,7 @@ import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.managed_mode
import org.meshtastic.core.resources.private_key
import org.meshtastic.core.resources.public_key
+import org.meshtastic.core.resources.redacted
import org.meshtastic.core.resources.regenerate_keys_confirmation
import org.meshtastic.core.resources.regenerate_private_key
import org.meshtastic.core.resources.security
@@ -128,20 +130,11 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
enabled = state.connected,
)
HorizontalDivider()
- EditBase64Preference(
- title = stringResource(Res.string.private_key),
- summary = stringResource(Res.string.config_security_private_key),
- value = formState.value.private_key,
+ SecurityPrivateKeyPreference(
+ securityConfig = securityConfig,
+ formState = formState,
enabled = state.connected,
- keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
- onValueChange = {
- if (it.size == 32) {
- formState.value = formState.value.copy(private_key = it)
- }
- },
- trailingIcon = {
- CopyIconButton(valueToCopy = formState.value.private_key.encodeToString(), sensitive = true)
- },
+ isLocal = state.isLocal,
)
HorizontalDivider()
NodeActionButton(
@@ -151,11 +144,15 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
icon = MeshtasticIcons.Warning,
onClick = { showKeyGenerationDialog = true },
)
- SecurityKeyBackupActions(
- viewModel = viewModel,
- enabled = state.connected,
- securityConfig = securityConfig,
- )
+ // Backup/restore operates on this phone's own key file, so it is meaningless for a remote node
+ // whose private key we never receive.
+ if (state.isLocal) {
+ SecurityKeyBackupActions(
+ viewModel = viewModel,
+ enabled = state.connected,
+ securityConfig = securityConfig,
+ )
+ }
}
}
item {
@@ -269,6 +266,50 @@ internal fun SecurityPublicKeyPreference(
)
}
+/**
+ * Firmware withholds the private key from remote admin responses, so a remote node reports an empty one. The field
+ * stays writable: a remote set of a new key is still honoured, and a set that omits it leaves the node's key alone.
+ */
+internal fun isPrivateKeyRedacted(securityConfig: Config.SecurityConfig, isLocal: Boolean): Boolean =
+ !isLocal && securityConfig.private_key.size != PRIVATE_KEY_SIZE
+
+@Composable
+internal fun SecurityPrivateKeyPreference(
+ securityConfig: Config.SecurityConfig,
+ formState: ConfigState<Config.SecurityConfig>,
+ enabled: Boolean,
+ isLocal: Boolean,
+) {
+ val focusManager = LocalFocusManager.current
+ val privateKey = formState.value.private_key
+ val redacted = isPrivateKeyRedacted(securityConfig, isLocal) && privateKey.size != PRIVATE_KEY_SIZE
+
+ EditBase64Preference(
+ title = stringResource(Res.string.private_key),
+ summary =
+ if (redacted) {
+ stringResource(Res.string.config_security_private_key_remote)
+ } else {
+ stringResource(Res.string.config_security_private_key)
+ },
+ value = privateKey,
+ enabled = enabled,
+ placeholderText = if (redacted) stringResource(Res.string.redacted) else null,
+ keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
+ onValueChange = {
+ if (it.size == PRIVATE_KEY_SIZE) {
+ formState.value = formState.value.copy(private_key = it)
+ }
+ },
+ trailingIcon =
+ if (privateKey.size == PRIVATE_KEY_SIZE) {
+ { CopyIconButton(valueToCopy = privateKey.encodeToString(), sensitive = true) }
+ } else {
+ null
+ },
+ )
+}
+
@Suppress("MagicNumber")
@Composable
fun PrivateKeyRegenerateDialog(

diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreenTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreenTest.kt
index 8258eddd63..775dbca405 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreenTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreenTest.kt
@@ -35,6 +35,8 @@ import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Config
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
@OptIn(ExperimentalTestApi::class)
class SecurityConfigScreenTest {
@@ -87,4 +89,18 @@ class SecurityConfigScreenTest {
onNodeWithTag(SECURITY_PUBLIC_KEY_COPY_TEST_TAG).performClick()
assertEquals(ByteString.EMPTY, copiedPublicKey)
}
+
+ @Test
+ fun `private key is redacted for a remote node until a new one is entered`() {
+ val remote = Config.SecurityConfig(public_key = ByteArray(32) { 1 }.toByteString())
+ assertTrue(isPrivateKeyRedacted(remote, isLocal = false))
+
+ // The local node always reports its own key, so nothing is withheld there.
+ val local = remote.copy(private_key = ByteArray(32) { 2 }.toByteString())
+ assertFalse(isPrivateKeyRedacted(local, isLocal = true))
+ assertFalse(isPrivateKeyRedacted(remote, isLocal = true))
+
+ // Older firmware that still returns the remote key has nothing left to redact.
+ assertFalse(isPrivateKeyRedacted(local, isLocal = false))
+ }
}

Served by rngit 1.5.0 - Generated in 0.09s